Release January 20, 2026 - #2582
Open
github-actions[bot] wants to merge 287 commits into
Open
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
github-actions
Bot
force-pushed
the
changeset-release/from-canary
branch
from
January 23, 2026 15:25
0fc4cb5 to
6629180
Compare
Slashes aren’t valid in Story slugs
…Storyblok Visual Editor Just returns the story as is outside of the visuel editor
Unnecessary as Storyblok already has a Slug field which does the same
RowPdp contains rows that are soly ment for product detail pages and require product data RowProduct contains rows that are ment to display products in grids, swipers, etc on any page
…ing & widen RowRenderer content type - useStoryblokState now narrows to StoryblokPage; global-config uses the upstream hook directly via the typed GlobalConfigStory returned by fetchGlobalConfig - RowRenderer accepts a wider content type so typed StoryblokPage body unions ([k: string]: unknown) are assignable
- fetchGlobalConfig and useStoryblokState throw a descriptive error in dev when content type doesn't match, return null in production - fetchStory/fetchStories log non-404 errors in dev so real failures (network, auth, malformed) are distinguishable from missing stories
to make sure all pages get fetched when passing the 100 story per fetch API limit - Fetch page 1, then derive remaining pages from response.total and fetch them in parallel chunks of 3 so large slug prefixes aren't capped at 100 stories - Add fetchWithRetry: retries 429s up to 3 times honoring the Retry-After header, lets all other errors propagate to the existing logFetchError path
Wrap the single-story fetch in fetchWithRetry so 429s during builds with many slugs are retried with Retry-After, matching fetchStories' behavior.
…pages - RowProduct accepts an optional items prop that bypasses the SKU resolve query, used when the parent already has products to surface - Category page wires a row_product renderer override that passes the first 8 category products, mirroring the Hygraph pattern
…routes
Magento renders links from base_link_url, which on a headless setup points
at the GraphCommerce storefront. Every stock email template contains a
customer/account/ link ("Sign in to your account") and gated Magento routes
302 to customer/account/login — GraphCommerce serves none of those, so
customers landed on a 404.
withGraphCommerce now adds permanent redirects for /customer/account,
/customer/account/index, /customer/account/login, /customer/account/create,
/customer/account/forgotpassword and /sales/order/history. They are exact
matches on purpose: redirects run before the filesystem routes, so a
/customer/account/:path* catch-all would shadow the
pages/customer/account/{confirm,createPassword} routes that
@graphcommerce/magento-customer copies into the project.
This restores the /customer/account redirect that was dropped as collateral
in "Remove redirects for /product/$type/[url] routes".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`multilinkHref` preferred the multilink's `cached_url`, which Storyblok freezes at the moment the *referencing* story is published. Renaming the target story does not rewrite it in already-published content, so every link to the renamed story kept emitting the old URL — a 404 — until an editor re-published each referencing story. `story.full_slug` is injected by `@storyblok/react` from the `links` map the CDN builds from the live story index on every request, and `sbParams` already sends `resolve_links: 'story'` on all reads, so it is populated for story links and always current. Prefer it, and fall through unchanged for `url` / `email` / `asset` links, which have no resolved story. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three sources of avoidable requests in the storefront's Storyblok layer:
- `refreshStoryblokCacheVersion()` issued `cdn/spaces/me` and expected
`storyblok-js-client` to pick the new cache-version up, but the client only
advances its pin from a response carrying a top-level `cv` — `cdn/spaces/me`
answers with `{ space: { version } }`. The pin therefore never moved and the
request was pure overhead. Read the version and apply it explicitly, and flush
the client's response cache when it changed so opt-in `apiOptions.cache` users
cannot serve content that outlived its version.
- `fetchStory()` had no memory of 404s. The client caches successful responses
only, so a catch-all route that probes the CMS for every category URL before
falling back re-asked the API on every render for slugs that never have a
story. Remember a 404 for one cache-version interval; draft/preview reads and
development bypass it, and only a genuine 404 is remembered so network and auth
failures stay retryable.
- `getStoryblokStaticPaths()` downloaded every story body to read `full_slug`.
The content sitemap that calls it runs in `getServerSideProps`, so that was the
whole space per crawler hit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: the entry had no evidence behind it. Grepping every stock Magento email template shows only three customer URLs — customer/account/ (48x), customer/account/createPassword (12x, carries id + rp_token) and customer/account/confirm (2x, carries id + key). forgotpassword appears in none of them; Magento's reset token rides on createPassword, and forgotpassword is just the form where a customer types their email. The remaining five are re-audited for shadowing. GraphCommerce serves exactly two paths under /customer: confirm and createPassword, both real pages in all three examples. None of the five sources collide with them (/customer/account/ create is an exact match and does not cover createPassword), and the comment now states the mechanism that makes this matter: a redirect wins over a filesystem route, so redirecting a path that has a page makes that page unreachable. Also corrects the comment's claim that @graphcommerce/magento-customer copies those pages in — it has no copy/ directory; they are scaffolded from the examples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…refer-resolved-full-slug fix(storyblok-ui): resolve story multilinks to the target's current slug
…omer-account-urls fix(next-config): redirect Magento account URLs to the GraphCommerce routes
Review feedback: remembering individual 404s does not help against the case it
most needs to — crawlers and vulnerability scanners walk an unbounded number of
made-up URLs, and a catch-all route asks the CMS about every one of them. Every
made-up URL is a fresh slug, so a per-slug negative cache never gets a second
chance to help and each one still costs a CDN request.
Replace it with a slug index: one `cdn/links` request per cache-version lists
every published slug in the space, and anything outside that set is answered as
"not found" without a request. Measured against a live space: 300 random URLs
now cost 0 requests instead of 300, while known slugs are unaffected.
- Deliberately unscoped by language. `cdn/links?language=x` returns only the
stories translated into that language (25 of 63 on the space I tested, for a
language the space does not even define), while `cdn/stories/<slug>?language=x`
falls back to the default language and resolves all of them — so a
language-scoped index would report existing pages as missing.
- Folders are skipped. A folder with a start page contributes that start page as
its own entry (`clubkleding/`), which is what makes `fetchStory('clubkleding')`
resolve while a folder without one correctly reports as missing.
- Memoized per cache-version in module scope rather than through the client's
response cache, because that cache is opt-in and off by default.
- Never claims absence when it does not know: a failed index request, or a space
larger than 10 000 stories, falls back to the previous per-slug behaviour.
Draft/preview reads and development skip the index entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add Google Places address autocomplete
`requestStoryblokCacheVersionRefresh()` — what a cache-notify webhook calls when a story is published — only flipped a flag in the process that happened to receive the webhook. Nothing told the other replicas anything had changed, so the only thing keeping them fresh was `cacheVersionTtl`: one `cdn/spaces/me` per server per interval, forever, whether or not anything is ever published. At the previous 60 second default that is ~450 000 requests a month across a ten-pod deployment, spent entirely on discovering that nothing happened. The refresh now fans out over a file instead. The webhook writes a millisecond timestamp to `renew-all-pages-query.txt` and every server compares it against the last value it acted on before a published read — a few bytes off the filesystem, throttled to once a second, never an API request. A server that sees a newer signal fetches the current cache-version once and pins it, so the cost of a publish is one request per server that actually serves traffic, instead of one request per server per minute forever. - The file name is not new: it is the one the SSR Apollo client convention already reads (`examples/*/lib/graphql/graphqlSsrClient.ts` drops its per-locale client when the timestamp moves past its own), so one publish can invalidate both caches and the contract stays "this file contains a number". - `storyblok.cacheVersionSignalDir` says where that file lives. It defaults to the `./tmp` of the existing convention, which resolves *inside each container* — a multi-replica deployment has to point it at a shared volume or the signal never leaves the pod that wrote it. `renewSignalPath()` is exported so a project's `graphqlSsrClient` can read the same file without hardcoding the path twice. - `cacheVersionTtl` now defaults to 3600 rather than 60. It is no longer what keeps content fresh, only what bounds staleness where the signal cannot be delivered: serverless, local development, an unwritable directory. It stays applied rather than disabled on purpose — a misconfigured webhook has to degrade to stale content, never to frozen content. - The real cache-version is still fetched rather than derived from the signal's timestamp. Storyblok answers a request carrying an unknown `cv` with a 301 to the canonical one, so a made-up value would work, but it costs every replica a redirect round-trip on its next read and keys the slug index on a version that is about to be replaced. - `node:fs` is loaded through a `webpackIgnore`d dynamic import. This module is re-exported from the package's single barrel entry point and Next.js does not tree-shake in development, so a static import would break every client component importing from the package. Draft, preview and development reads are untouched — they already send a fresh `cv` per request. Verified with ten real OS processes against one shared directory: before, a publish reached 1 of 10 processes and the other nine kept serving the pre-publish version; after, all 10 picked it up within a second, at one `cdn/spaces/me` each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useClearCurrentCartId()` returned a bare arrow function and `useCartLock()` defined `lock`/`unlock` inline, so all three got a new identity on every render. Consumers that correctly list them in a `useEffect` dependency array therefore re-ran that effect on every render. `useClearCurrentCartId` now uses `useCallback` with `[cache]`, matching its neighbour `useAssignCurrentCartId`. `lock`/`unlock` use `useEventCallback` because they read values that legitimately change (`currentCartId`, the router query state, the Apollo client); that keeps the identity stable without introducing a stale closure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…identities fix(magento-cart): stable identities for cart clear and cart lock hooks
The renew signal was a file in a configurable directory, which only fans out when every replica happens to mount the same volume — the constraint that makes the `./tmp` convention in `examples/*/lib/graphql/graphqlSsrClient.ts` unreliable in the first place. `storyblok.cacheVersionSignalDir` made that a supported option rather than fixing it, so both the config key and the filesystem access are gone. The signal now goes through `globalThis.__incrementalCache`, so it reaches every server that shares a `cacheHandler` — the same requirement a deployment already has to meet to serve consistent ISR, and nothing extra to configure. Validated on a two-pod cluster: pod A writes, pod B reads the value within 9 ms. This leans on two Next.js internals. The global is set in `handleRequestImpl` (`next/dist/server/base-server.js`) on every incoming request, ahead of API routes and ISR regeneration, under the same name and shape in Next 13 through 16; middleware and the edge runtime build their own instance and are not covered. Writing under `kind: 'FETCH'` with `fetchCache: true` is the one path that stores a key verbatim rather than through `normalizePagePath()`, which is what allows a namespaced key. Both are unstable: if either changes, reads and writes fail closed and freshness falls back to `cacheVersionTtl`. The call shape has drifted before — `revalidate` became `cacheControl` between Next 14 and 15 — so it needs re-testing per major. Why the push is worth having at all: `cdn/spaces/me` polls are billed per request against the Storyblok quota, so the polling interval is a cost, not just a staleness knob. `cacheVersionTtl` stays at 3600 as the failsafe. Also drops the justification prose from the comments and rewrites both changesets to describe what changes rather than argue for it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blok Two mechanisms were solving the same problem in different ways: Storyblok pushed a renew signal through the incremental cache, while `graphqlSsrClient()` read `./tmp/renew-all-pages-query.txt`. That path resolves inside each container, so on a multi-replica deployment it silently invalidated nothing — the assumption that made the file approach wrong for Storyblok in the first place. The signal moves to `@graphcommerce/graphql` as `publishRenewSignal()` / `renewSignal()` / `refreshRenewSignal()`. That package is the right home by dependency direction: `storyblok-ui` peer-depends on it, it does not depend on `storyblok-ui`, and all three examples already import from it. The key is neutral (`gc:signal:content-renew`) because the fact being published is "content changed", not anything Storyblok-specific. `graphqlSsrClient()` is synchronous and called as `const client = graphqlSsrClient(context)` throughout `getStaticProps`, so it cannot await the read; making it async would break every consumer in every project. It reads the last known value and schedules the next refresh, which bounds staleness at one poll interval plus one call. That makes the cold start explicit rather than accidental. `renewSignal()` returns `undefined` until the first read completes, which is distinct from "read, and nothing was published" — every pod is in that state right after a deploy, and treating it as 0 would silently skip the first invalidation. Callers create the client without invalidating (a client made now cannot predate a publish) and let the scheduled read decide the next call. Concurrent callers share one in-flight read, so a fresh pod taking simultaneous requests does not open one per `getStaticProps`. Also sets the `cacheVersionTtl` schema default to 3600 to match the repo convention. The `?? 3600` fallback stays: schema defaults are applied by the zod parse but not carried into the generated config values, which is what the runtime imports — `configurableVariantForSimple` has a `= false` default and still generates `undefined`. Verified against a stand-in incremental cache: 25 concurrent refreshes cost one read, a second process picks up a publish, and the client is recreated once per publish and stable in between. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er-api-calls fix(storyblok-ui): cut redundant Storyblok CDN requests
…ignment feat!: GraphCommerce 11 — drop Node 20, require Node 22+
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated release PR from canary branch